fix: bound snappy uncompressed length to avoid OOM on untrusted block#5
Merged
Merged
Conversation
moshap-firebolt
force-pushed
the
moshap/bound-snappy-uncompress-oom
branch
2 times, most recently
from
July 19, 2026 15:59
1c6b4f3 to
25575c7
Compare
lorenzhs
previously approved these changes
Jul 20, 2026
| // tiny scratch buffer). snappy::Uncompress(Source*, Sink*) then sees a scratch | ||
| // smaller than the declared uncompressed length and takes its block-by-block | ||
| // scattered path, allocating in bounded (~64 KiB) increments as bytes are | ||
| // actually decompressed. This is what keeps an untrusted length prefix from |
Collaborator
There was a problem hiding this comment.
since this just appends to a std::string, it uses std::string's growing implementation, which is not in 64 kiB increments but usually doubling on each resize (which is actually what we want).
Author
There was a problem hiding this comment.
Good catch — corrected the comment. Dropped the "~64 KiB increments" phrasing (that's snappy's internal scratch-block size, not the destination); the comment now says the destination std::string grows with its usual amortized doubling as blocks are appended. Behavior unchanged; comment-only amend.
## Problem DataFileReaderBase::readDataBlock() decompressed a snappy-coded OCF block with the std::string overload of snappy::Uncompress(), which reads the declared uncompressed length from the varint prefix of the block and resize()s the destination to that full length before decompressing a single byte. The length is attacker-controlled when the Avro data is untrusted (e.g. Iceberg manifest / data files or `... FORMAT Avro` in PackDB). A tiny crafted block can declare a multi-GiB uncompressed length, triggering a huge allocation -- an OOM -- before the decoder discovers the compressed payload is far shorter. Surfaced by PackDB's `avro_input` libFuzzer harness: two independent snappy OCF crash inputs (220 B and ~1 KB) each drove a ~2.4 GiB malloc, reported as a libFuzzer out-of-memory. Same class as the decodeString/decodeBytes OOM fixed in #4, but a distinct code path (the snappy block decompress, not the binary decoder). ## Fix Read the declared uncompressed length without allocating (GetUncompressedLength) and pick a decode strategy from it -- this never rejects any block: - modest length (<= 64 MiB): decompress in one shot into a pre-sized buffer, the original fast path with no extra copy (the overwhelmingly common case); - implausibly large length: decompress through SnappyStringSink, whose scattered path allocates the output in bounded ~64 KiB increments as bytes are produced, so a bogus length aborts once the compressed input is exhausted (having allocated only what was really produced) while a genuinely large block still decodes, just incrementally. This keeps the bounded-growth safety of a pure sink approach (matching the #4 strategy of allocating proportional to the bytes actually present) without a fixed expansion cap and without regressing the common fast path. The 64 MiB switch point bounds the worst-case up-front allocation a crafted block can force onto the fast path and comfortably exceeds real Avro block sizes. ## Test - Reproduced the OOM against the PackDB `avro_input` fuzzer on two independent crash inputs; both run clean (exit 0, 0-1 ms) with this change. - Well-formed snappy Avro fixtures (weather-snappy, nulls, datapage_v2, alltypes_plain) still decode identically. - Microbenchmark (flat vs this hybrid, -O2) across 16 KiB..16 MiB blocks and ratios 2x..50x: hybrid is within noise of the original flat path (0.9-1.0x). Only blocks whose declared length exceeds 64 MiB take the ~3x-slower but bounded incremental path. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
moshap-firebolt
force-pushed
the
moshap/bound-snappy-uncompress-oom
branch
from
July 20, 2026 16:08
25575c7 to
e3f1fea
Compare
lorenzhs
approved these changes
Jul 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
DataFileReaderBase::readDataBlock()decompressed a snappy-coded OCF block with the std::string overload ofsnappy::Uncompress(), which reads the declared uncompressed length from the varint prefix of the block andresize()s the destination to that full length before decompressing a single byte. The length is attacker-controlled for untrusted Avro input (Iceberg manifest/data files,... FORMAT Avroin PackDB). A tiny crafted block can declare a multi-GiB uncompressed length → huge allocation → OOM before the reader notices the compressed payload is far shorter.Surfaced by PackDB's
avro_inputlibFuzzer harness: two independent crash inputs (220 B and ~1 KB) each drove a ~2.4 GiBmalloc. Same class as thedecodeString/decodeBytesOOM fixed in #4, different code path. Present verbatim in Apache Avro C++main— not fixed upstream.Fix
Read the declared length without allocating (
GetUncompressedLength) and pick a decode strategy — this never rejects any block:SnappyStringSink, whose scattered path allocates the output in bounded ~64 KiB increments as bytes are produced, so a bogus length aborts once the compressed input is exhausted (allocating only what was really produced), while a genuinely large block still decodes — just incrementally.This keeps the bounded-growth safety of a pure-sink approach (same spirit as #4 — allocate proportional to the bytes actually present) with no fixed expansion cap and no regression on the common fast path. The 64 MiB switch point bounds the worst-case up-front allocation a crafted block can force onto the fast path and comfortably exceeds real Avro block sizes.
Why not a pure Sink (the obvious approach)?
Decompressing everything through the sink is safe but slow: snappy's scattered path does an extra full copy of the output plus many 64 KiB allocations instead of one contiguous decompress. That is a real regression on multi-MiB blocks (see below), which is the hot path for Iceberg reads. The hybrid confines the sink to the only case that needs it.
Why not a fixed expansion cap?
A "reject if uncompressed/compressed > N" cap can reject legitimately highly-compressible data (snappy can exceed any fixed ratio on repetitive input). The strategy-switch rejects nothing — large blocks still decode, just via the bounded path.
Performance
Microbenchmark of the changed operation in isolation (snappy decompress only,
clang++-22 -O2 -DNDEBUG, in-process; each row ~fixed total work with warmup).flat= original std::string overload,pure-sink= decompress everything through the sink,hybrid= this PR.Test
weather-snappy,nulls.snappy,datapage_v2.snappy,alltypes_plain.snappy) decode identically — the sink appends exactly the bytes snappy produces, and the flat path is unchanged.🤖 Generated with Claude Code